[Adapters] Implement EmdashCartStore with an embedded mutation ledger and intent-claimed inventory edges - #255
Merged
Conversation
… and intent-claimed inventory edges
The cart is the first commerce aggregate whose invariants cross into another one,
so the shape is explicit rather than implied. `carts/{cartId}` carries the lines
map keyed by sku (the old `(cart_id, sku)` unique index, made structural), the
embedded mutation ledger (the old `cart_mutations` table, read and written in the
SAME conditional write as the line it records) and a denormalized `holdExpiresAt`
(the old `expires_at <= now` scan target, since the filter algebra has no OR and
cannot reach inside a map). One lookup collection, `cart_mutation_index`, answers
the two port signatures that are handed an identifier with no cart id — and that
second hop is also the sweep's scoping, because a raw reserve has no cart claim.
Every mutation that touches stock is a bracket: claim in the ledger, the inventory
movement through `InventoryStore` and nothing else, then a completion that lands
the line and `completed: true` together. Hold expiry is the same shape with a
once-only token — onto the line, or onto the outstanding claim when the crash left
no line — so a partial expiry is completable by any replayer, only the minter
reports the reclaim, and stock returns exactly once. A fresh token is refused
while the reservation is already terminal, which is the obligation the inventory
tier hands every reaping path.
THE ATTACH GUARD IS A GUARDED WRITE, as `CartStore.upsertLine`'s contract requires:
the deadline stamp and the guard are one act, and a mere read cannot substitute
because the sweep can reap the hold between the read and the cart write. Since the
port declares no such method and widening it is a domain change this package may
not make, the capability is adapter-local — `HoldDeadlineStamper.stampHoldDeadline`,
implemented by `EmdashInventoryStore` as one compare-and-set in which the
`state = 'held'` precondition, the ownership check and the new deadline commit
together, returning false for an unknown, pruned or adopted hold and never touching
a non-held one. `EmdashCartStore` asks for `InventoryStore & HoldDeadlineStamper`
and calls it inside the compare-and-set step of `upsertLine` and `adjustLine`,
before the cart write (the SQL's fixed step order), so the guard is re-evaluated on
every attempt; a refusal is the port's `HoldExpiredError`. That also keeps
`adopt`/`adoptMany`'s `expiresAt > now` scope satisfied for cart holds, which a
cart-only deadline would have broken outright, and it is what makes the two
tolerated `release` refusals recognizable by type rather than by message.
`adjustLine`'s reconcile is a REPAIR, not a retry: once the key is completed the
mutation must never re-apply, but the stored qty still owes the hold agreement, so
a divergence is fixed in place with the completion preserved — a bare retry would
find `completed` and hand back the stale line.
Tests: the domain contract runs green on sqlite, Postgres and D1 with no skips; the
three dialect suites and the cart race gate are ported off the SQL adapter (the
crashed-hold state is now PRODUCED by the real claim and reserve rather than
hand-seeded); a new seams suite opens the interruption points, and the file states
which of its cases inject a fault and which do not rather than claiming all do; a
regression case drives `adoptMany` over a hold the cart attached, which is the only
thing that would notice the stamp going missing; a Postgres case races two
different-key adjusts and pins the convergence the repair buys. The inventory
contract harness now calls the real `stampHoldDeadline` instead of patching the
document by hand.
Also types the inventory store's `release` refusal on a non-live hold — the bare
`Error` becomes `ReservationNotReleasableError` with the same message — and fixes
the README's recorded drift on the merchant removal shape's measured contention
failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
EmdashCartStoreimplements every method of the domain'sCartStoreport over one aggregatedocument per cart, with lines keyed by SKU and an embedded, bounded mutation ledger recorded in
the same conditional write as the line it describes. Every mutation that touches stock — adding or
adjusting a line, expiring a hold, checking out — is written as an explicit bracket: claim the
intent in the ledger, run the inventory movement through
InventoryStoreand nothing else, thenland a completion that any replayer can finish if the process dies mid-bracket. This is the tenth
increment of the effort to fold the commerce service into the EmDash plugin (work order 02).
Design points
records and the last 16 abandoned (reaped-crash) records, evicting the oldest of each. A record
that is claimed but neither completed nor abandoned is never pruned, at any age — it's the only
thing that tells a replayer a mutation is still owed.
mutation by key and expiring a hold by reservation id — are handed an identifier with no cart id
attached. A small lookup collection maps those keys back to their owning cart, and it also scopes
the expiry sweep: a reservation with no cart claim in the locator can never be reaped by the cart
side.
the inventory side happens through a small adapter-local extension on the inventory store, and
it's applied as a guarded compare-and-swap step nested inside the cart's own CAS — never as a
separate read followed by a write. That ordering means a hold that has already been reaped, or
whose reservation has already settled, can never receive a new deadline; the guard fails instead,
and the port's
HoldExpiredErrorfires exactly where it should. This is what makes checkout ableto adopt a cart's holds safely, and it's pinned by a new regression test that specifically
reproduces the case where the stamp goes missing.
expireHoldfirst commits a once-only expiry token, thenperforms an idempotent release of the reserved stock, then removes the line — in that order — so
that no matter where a crash lands, a replayer returns the stock exactly once and never twice.
single conditional write, so a crash either lands the whole transition or none of it.
adjustLinerepairs rather than retries. Once a mutation key is marked completed it neverre-applies, but the line's stored quantity can still drift from the hold it references if the two
writes race; a dedicated repair pass reconciles the line to the hold in place, with the completion
preserved. A two-writer convergence race test proves both writers land on the same, correct end
state.
against a hold that is no longer live, now raises a specific, classifiable error type instead of
succeeding silently or behaving ambiguously — closing the last untyped failure mode in the expiry
path.
Crash seams proven
exactly once.
hold to the line without a second stock movement.
replay finishes the release and returns stock exactly once.
now-stale line without returning stock a second time.
the already-recorded order id.
rather than force-expired, because settling and pruning belong to the sweep, not the cart.
line can ever attach itself to units that are already spent.
ambiguous or silent outcome.
Verification
Review
Two independent reviews were conducted over three rounds. The first round caught that dropping the
hold-deadline stamp would have broken every checkout; this was fixed by making the stamp a guarded
write, with a new regression test case added to pin the behavior. The second round asked for two
one-line hardening fixes, both applied. Both reviewers approve. An independent verification run
passed twice on Postgres and once on D1.
🤖 Generated with Claude Code
https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X